--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ae6829dd8f0918936f2ee30a256b0d127620109b
Parents : 86a32db
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T04:54:41-05:00
feat: introduce Extended Edge Case Tester (EECT) and Live Validation (LV) with new testing scenarios and log redaction improvements
Changes
40 files changed, 1589 insertions(+), 55 deletions(-)
Diff
diff --git a/.gitignore b/.gitignore
index 5362a223..c84250f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -168,6 +168,9 @@ electron/backend-manifest.json
scripts/private/
*.exe
+# Accidental ImageMagick / print dumps (e.g. root-level "re" PostScript)
+*.ps
+*.eps
# Local identity and rnid-style artifacts
*.rid
*.rsg
diff --git a/Taskfile.yml b/Taskfile.yml
index 6bad3120..84e6e3f5 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -264,7 +264,7 @@ tasks:
fi
test:quick:be:
- desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads, RNS link)
+ desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads, RNS link, EECT packs)
cmds:
- >-
uv run pytest
@@ -276,6 +276,22 @@ tasks:
tests/backend/test_rns_link_fuzzing.py
tests/backend/test_rns_link_plugin.py
-q
+ - task: test:eect
+
+ test:eect:
+ desc: Extended Edge Case Tester packs (seeded identity/path/auth/hostile/scarcity scenarios)
+ cmds:
+ - uv run pytest tests/backend/eect/packs -m eect -q --tb=short
+
+ test:lv:
+ desc: Live Validation ladder (set MESHCHAT_LIVE_VALIDATION=1 for L2-L3)
+ cmds:
+ - MESHCHAT_LIVE_VALIDATION=1 uv run pytest tests/backend/eect/live -m live_validation -q --tb=short
+
+ test:lv:l0:
+ desc: Live Validation L0 only (imports/sqlite/unicode, CI-safe)
+ cmds:
+ - uv run pytest tests/backend/eect/live/test_lv_ladder.py -m live_validation -k "l0" -q --tb=short
test:quick:fe:
desc: Frontend regression subset (links, micron preview, stranger banner, interface stats)
diff --git a/docs/agents/conventions/tests.md b/docs/agents/conventions/tests.md
index ea023591..c2c00f8e 100644
--- a/docs/agents/conventions/tests.md
+++ b/docs/agents/conventions/tests.md
@@ -8,3 +8,11 @@ Applies when editing `tests/**/*.{py,js}`.
- Prefer focused files over full suite unless the user asks for broad runs.
- Landlock tests that apply the sandbox must run in a subprocess (one restrict per process).
- Long-running / notification soak suites can hang. Prefer timeouts and avoid piping pytest through `tail` in agent shells.
+
+## Extended Edge Case Tester (EECT) and Live Validation (LV)
+
+- EECT packs live under `tests/backend/eect/packs/` and use marker `eect`.
+- LV ladder lives under `tests/backend/eect/live/` and uses marker `live_validation`.
+- Replay a failure with `MESHCHAT_EECT_SEED=<seed>` (printed on assert failure).
+- Commands: `task test:eect`, `task test:lv:l0`, `MESHCHAT_LIVE_VALIDATION=1 task test:lv`.
+- LV L2/L3 are opt-in (`MESHCHAT_LIVE_VALIDATION=1` or `MESHCHAT_LIVE_RETICULUM=1`). L0/L1 stay CI-safe.
diff --git a/docs/agents/overview.md b/docs/agents/overview.md
index 5db2e6fb..4210c8a9 100644
--- a/docs/agents/overview.md
+++ b/docs/agents/overview.md
@@ -102,6 +102,8 @@ task install
task format
task lint
task test:quick
+task test:eect
+task test:lv:l0
task test:backend
task test:frontend
task test:e2e
diff --git a/docs/agents/skills/reticulum-design-gates/SKILL.md b/docs/agents/skills/reticulum-design-gates/SKILL.md
index afb53758..9a8167cf 100644
--- a/docs/agents/skills/reticulum-design-gates/SKILL.md
+++ b/docs/agents/skills/reticulum-design-gates/SKILL.md
@@ -88,6 +88,7 @@ Allowed:
- [ ] No new unauthenticated mutating HTTP/WS surface
- [ ] No cross-identity leakage
- [ ] Tests cover success and recoverable failure
+- [ ] Mesh/identity/auth changes cite matching EECT scenario ids under `tests/backend/eect/`
## Key references
diff --git a/docs/agents/skills/test-loop/SKILL.md b/docs/agents/skills/test-loop/SKILL.md
index 82f0a989..46a8fa2a 100644
--- a/docs/agents/skills/test-loop/SKILL.md
+++ b/docs/agents/skills/test-loop/SKILL.md
@@ -22,6 +22,11 @@ pnpm exec vitest run tests/frontend/<Name>.test.js
# Quick regression
task test:quick
+# Extended Edge Case Tester / Live Validation
+task test:eect
+task test:lv:l0
+MESHCHAT_LIVE_VALIDATION=1 task test:lv
+
# Broader
task test:backend
task test:frontend
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 125c9063..c0d3f116 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/backend/bug_report_manager.py b/meshchatx/src/backend/bug_report_manager.py
index 0eb297db..d063dde0 100644
--- a/meshchatx/src/backend/bug_report_manager.py
+++ b/meshchatx/src/backend/bug_report_manager.py
@@ -13,6 +13,7 @@ from typing import Any
import RNS
from meshchatx.src.backend.announce_handler import AnnounceHandler
+from meshchatx.src.backend.log_redaction import redact_diagnostic_text
BUG_ASPECT = "mcx-bugs-v1"
REPORT_PATH = "/report"
@@ -386,7 +387,7 @@ class BugReportManager:
module = entry.get("module") or ""
message = entry.get("message") or ""
lines.append(f"{ts}\t{level}\t{module}\t{message}")
- log_text = "\n".join(lines)
+ log_text = redact_diagnostic_text("\n".join(lines))
if len(log_text) > MAX_PAYLOAD_CHARS:
log_text = log_text[:MAX_PAYLOAD_CHARS] + "\n[truncated]"
return {
diff --git a/meshchatx/src/backend/favourites_layout.py b/meshchatx/src/backend/favourites_layout.py
index ced477f6..9654c9f5 100644
--- a/meshchatx/src/backend/favourites_layout.py
+++ b/meshchatx/src/backend/favourites_layout.py
@@ -43,6 +43,7 @@ def normalize_favourites_layout(layout):
section_id = section_id.strip()
if (
not section_id
+ or "\x00" in section_id
or len(section_id) > MAX_SECTION_ID_LEN
or section_id in section_ids
or section_id in _FORBIDDEN_SECTION_IDS
@@ -99,7 +100,7 @@ def normalize_favourites_layout(layout):
if not isinstance(item, str):
continue
h = item.strip()
- if not h or len(h) > MAX_HASH_LEN or h in seen:
+ if not h or "\x00" in h or len(h) > MAX_HASH_LEN or h in seen:
continue
seen.add(h)
hashes.append(h)
@@ -117,9 +118,15 @@ def normalize_favourites_layout(layout):
def layout_payload_too_large(raw_body_len):
- """Return True when a raw request body exceeds the layout size budget."""
+ """Return True when a raw request body exceeds the layout size budget.
+
+ Negative sizes are treated as oversize so hostile Content-Length values
+ cannot skip the early reject path.
+ """
try:
size = int(raw_body_len)
except (TypeError, ValueError):
return False
+ if size < 0:
+ return True
return size > MAX_LAYOUT_JSON_BYTES
diff --git a/meshchatx/src/backend/http_url_guard.py b/meshchatx/src/backend/http_url_guard.py
index 2b460b77..c19d2d27 100644
--- a/meshchatx/src/backend/http_url_guard.py
+++ b/meshchatx/src/backend/http_url_guard.py
@@ -51,6 +51,27 @@ def normalize_loopback_http_service_base(url: str) -> str:
_WS_CTRL = re.compile(r"[\x00-\x20\x7f]")
+def _coerce_host_to_ip(
+ host: str,
+) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
+ """Parse dotted/colon IPs plus decimal and 0x hex IPv4 host forms browsers accept."""
+ if not host:
+ return None
+ try:
+ return ipaddress.ip_address(host)
+ except ValueError:
+ pass
+ # Integer / hex IPv4 (e.g. 2852039166, 0xa9fea9fe -> 169.254.169.254).
+ try:
+ if host.startswith("0x") or host.startswith("0X"):
+ return ipaddress.IPv4Address(int(host, 16))
+ if host.isdigit():
+ return ipaddress.IPv4Address(int(host))
+ except (ValueError, OverflowError):
+ return None
+ return None
+
+
def normalize_libretranslate_http_service_base(url: str) -> str:
"""Return scheme://host:port with no path, query, or fragment.
@@ -58,8 +79,8 @@ def normalize_libretranslate_http_service_base(url: str) -> str:
public API). Embedded credentials are rejected; non-http(s) schemes are rejected.
Literal IPv4 link-local targets (169.254.0.0/16) are rejected as a common SSRF/metadata
- path. Other private or loopback addresses are allowed so local servers and overlays (e.g. VPN
- mesh) continue to work.
+ path, including decimal and hex encodings of those addresses. Other private or loopback
+ addresses are allowed so local servers and overlays (e.g. VPN mesh) continue to work.
"""
if not url or not isinstance(url, str):
msg = "URL must be a non-empty string"
@@ -91,11 +112,8 @@ def normalize_libretranslate_http_service_base(url: str) -> str:
raise UnsafeOutboundUrlError(msg)
host_for_ip_check = host_decoded.lower().strip("[]")
- try:
- addr = ipaddress.ip_address(host_for_ip_check)
- except ValueError:
- pass
- else:
+ addr = _coerce_host_to_ip(host_for_ip_check)
+ if addr is not None:
if addr.version == 4 and addr.is_link_local:
msg = "URL must not target an IPv4 link-local address"
raise UnsafeOutboundUrlError(msg)
diff --git a/meshchatx/src/backend/log_redaction.py b/meshchatx/src/backend/log_redaction.py
new file mode 100644
index 00000000..40027c41
--- /dev/null
+++ b/meshchatx/src/backend/log_redaction.py
@@ -0,0 +1,66 @@
+# SPDX-License-Identifier: 0BSD
+"""Redact sensitive fragments from diagnostic / bug-report log text."""
+
+from __future__ import annotations
+
+import re
+
+# Absolute Unix/Windows path-like tokens (kept conservative to avoid eating hex).
+_ABS_PATH_RE = re.compile(
+ r"(?:"
+ r"(?:/(?:home|Users|tmp|var|etc|opt|usr|root|run|mnt|media|data|srv|private|"
+ r"Volumes|Library|Applications)[^\s\"']*)"
+ r"|(?:[A-Za-z]:\\[^\s\"']+)"
+ r"|(?:\\\\[^\s\"']+)"
+ r")",
+ re.IGNORECASE,
+)
+
+# Full 32-byte hex hashes (RNS destination / identity). Partial prefixes stay.
+_FULL_HEX_HASH_RE = re.compile(r"(?<![0-9a-fA-F])([0-9a-fA-F]{64})(?![0-9a-fA-F])")
+
+# Private-key-ish long hex blobs (>= 96 hex chars continuous).
+_LONG_HEX_RE = re.compile(r"(?<![0-9a-fA-F])([0-9a-fA-F]{96,})(?![0-9a-fA-F])")
+
+# Email-ish tokens.
+_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
+
+# IPv4 addresses (not link-local mesh addressing; UI logs should not leak host IPs by default).
+_IPV4_RE = re.compile(
+ r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
+)
+
+# PEM private key / certificate blocks.
+_PEM_RE = re.compile(
+ r"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----",
+ re.IGNORECASE,
+)
+
+# Bearer / JWT-ish tokens and common secret assignments.
+_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._\-+=/]{8,}", re.IGNORECASE)
+_SECRET_ASSIGN_RE = re.compile(
+ r"\b(?:alias_identity_private_key|private_key|session|password|passwd|token|"
+ r"api[_-]?key|csrf|authorization)\s*[:=]\s*\S+",
+ re.IGNORECASE,
+)
+
+REDACTED = "[redacted]"
+
+
+def redact_diagnostic_text(text: str) -> str:
+ """Return text with paths, full hashes, emails, and IPv4s replaced.
+
+ Partial destination hashes (shorter than 64 hex chars) are left alone so
+ operators can still correlate short display prefixes.
+ """
+ if not text:
+ return text
+ out = _PEM_RE.sub(REDACTED, text)
+ out = _ABS_PATH_RE.sub(REDACTED, out)
+ out = _LONG_HEX_RE.sub(REDACTED, out)
+ out = _FULL_HEX_HASH_RE.sub(REDACTED, out)
+ out = _EMAIL_RE.sub(REDACTED, out)
+ out = _IPV4_RE.sub(REDACTED, out)
+ out = _BEARER_RE.sub(f"Bearer {REDACTED}", out)
+ out = _SECRET_ASSIGN_RE.sub(REDACTED, out)
+ return out
diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.js
index 12d9dc3f..d7a80266 100644
--- a/meshchatx/src/frontend/js/DownloadUtils.js
+++ b/meshchatx/src/frontend/js/DownloadUtils.js
@@ -9,13 +9,18 @@ function isAndroidSaveBridge() {
class DownloadUtils {
static sanitizeDownloadFilename(filename, defaultFilename = "download") {
let name = filename == null ? "" : String(filename);
- // eslint-disable-next-line no-control-regex -- strip CR/LF/NUL from peer-provided names
- name = name.replace(/[\r\n\x00]/g, "").trim();
+ // eslint-disable-next-line no-control-regex -- strip CR/LF/NUL and bidi overrides from peer-provided names
+ name = name.replace(/[\r\n\x00\u202A-\u202E\u2066-\u2069]/g, "").trim();
// Drop path segments from naive Content-Disposition or peer-provided names.
name = name.split(/[/\\]/).pop() || "";
+ name = name.replace(/[. ]+$/g, "");
if (!name || name === "." || name === "..") {
return defaultFilename;
}
+ const stem = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) {
+ return defaultFilename;
+ }
return name;
}
diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 86359869..cff72244 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -89,6 +89,8 @@ export default class MicronParser extends BaseMicronParser {
}
});
cleaned = cleaned.replace(/\\(.)/g, "$1");
+ // Drop format/bidi noise so fi\u200Bxed / soft-hyphen cannot hide overlays.
+ cleaned = cleaned.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, "");
const declarations = cleaned.split(";").filter(Boolean);
const safe = declarations.filter((decl) => {
const colon = decl.indexOf(":");
@@ -101,6 +103,7 @@ export default class MicronParser extends BaseMicronParser {
.trim()
.toLowerCase()
.replace(/!important/g, "")
+ .replace(/\s+/g, "")
.trim();
if (prop === "position" && (/\bfixed\b/.test(val) || /\bsticky\b/.test(val))) {
return false;
diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
index 9e7a5a40..ebc7aba1 100644
--- a/meshchatx/src/frontend/js/NomadPageRenderer.js
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -81,8 +81,10 @@ export function stripOverlayFromCss(css) {
}
});
s = s.replace(/\\(.)/g, "$1");
+ // Drop format/bidi noise so fi\u200Bxed cannot hide overlays.
+ s = s.replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g, "");
s = s.replace(/position\s*:\s*[^;{}]+/gi, (decl) => {
- const lower = decl.toLowerCase();
+ const lower = decl.toLowerCase().replace(/\s+/g, "");
if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower)) {
return "position:static";
}
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index 1fee38b8..2d37978f 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -54,16 +54,21 @@ out vec4 outColor;
void main() {
float d = length(v_uv);
if (d > 1.0) discard;
- float edge = smoothstep(1.0, 0.72, d);
+ float edge = smoothstep(1.0, 0.82, d);
+ float rim = smoothstep(0.92, 1.0, d);
if (v_useTex > 0.5) {
vec2 local = v_uv * 0.5 + 0.5;
vec2 texUV = v_uvOrigin + local * u_cellUv;
vec4 tex = texture(u_atlas, texUV);
- float a = tex.a * edge * v_color.a;
+ // Opaque RGB icons (and RGB canvases) may report a=0 in some uploads.
+ float texA = max(tex.a, 0.001);
+ float a = texA * edge * v_color.a;
if (a < 0.01) discard;
- outColor = vec4(tex.rgb, a);
+ vec3 rgb = mix(tex.rgb, v_color.rgb, rim * 0.85);
+ outColor = vec4(rgb, a);
} else {
- outColor = vec4(v_color.rgb, v_color.a * edge);
+ vec3 rgb = mix(v_color.rgb * 0.92, v_color.rgb, 1.0 - rim);
+ outColor = vec4(rgb, v_color.a * edge);
}
}
`;
@@ -187,6 +192,28 @@ export function tryCreateWebGL2Context(canvas) {
/**
* @param {WebGL2RenderingContext} gl
*/
+/**
+ * Resolve a same-origin asset path against the Vite/app base URL.
+ * Absolute http(s)/blob/data URLs are returned unchanged.
+ * @param {string} url
+ * @returns {string}
+ */
+export function resolveVisualiserAssetUrl(url) {
+ if (!url || typeof url !== "string") return "";
+ const trimmed = url.trim();
+ if (!trimmed) return "";
+ if (/^(?:blob:|data:|https?:|file:)/i.test(trimmed)) return trimmed;
+ if (typeof window !== "undefined" && window.location?.origin && trimmed.startsWith("/")) {
+ const base = typeof import.meta !== "undefined" && import.meta.env?.BASE_URL ? import.meta.env.BASE_URL : "/";
+ const root = String(base || "/").replace(/\/?$/, "/");
+ if (root !== "/" && !trimmed.startsWith(root)) {
+ return `${window.location.origin}${root.replace(/\/$/, "")}${trimmed}`;
+ }
+ return `${window.location.origin}${trimmed}`;
+ }
+ return trimmed;
+}
+
function createIconAtlas(gl) {
const width = ATLAS_COLS * ATLAS_CELL;
const height = ATLAS_ROWS * ATLAS_CELL;
@@ -207,7 +234,7 @@ function createIconAtlas(gl) {
scratch.width = ATLAS_CELL;
scratch.height = ATLAS_CELL;
}
- const scratchCtx = scratch?.getContext?.("2d") || null;
+ const scratchCtx = scratch?.getContext?.("2d", { willReadFrequently: true }) || null;
function allocSlot() {
if (freeSlots.length > 0) return freeSlots.pop();
@@ -216,31 +243,74 @@ function createIconAtlas(gl) {
}
function paintSlot(slot, source) {
- if (!scratchCtx || !scratch) return;
+ if (!scratchCtx || !scratch) return false;
+ scratchCtx.save();
+ scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
scratchCtx.clearRect(0, 0, ATLAS_CELL, ATLAS_CELL);
const sw = source.width || source.videoWidth || ATLAS_CELL;
const sh = source.height || source.videoHeight || ATLAS_CELL;
+ if (!(sw > 0 && sh > 0)) {
+ scratchCtx.restore();
+ return false;
+ }
const scale = Math.min(ATLAS_CELL / sw, ATLAS_CELL / sh);
const dw = Math.max(1, Math.floor(sw * scale));
const dh = Math.max(1, Math.floor(sh * scale));
const dx = Math.floor((ATLAS_CELL - dw) / 2);
const dy = Math.floor((ATLAS_CELL - dh) / 2);
scratchCtx.drawImage(source, dx, dy, dw, dh);
+ // Force full opacity for RGB sources that leave alpha at 0 after upload.
+ const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL);
+ const data = pixels.data;
+ let painted = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i] | data[i + 1] | data[i + 2] | data[i + 3]) {
+ data[i + 3] = 255;
+ painted += 1;
+ }
+ }
+ if (painted < 8) {
+ scratchCtx.restore();
+ return false;
+ }
+ scratchCtx.putImageData(pixels, 0, 0);
+ scratchCtx.restore();
const col = slot % ATLAS_COLS;
const row = Math.floor(slot / ATLAS_COLS);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texSubImage2D(gl.TEXTURE_2D, 0, col * ATLAS_CELL, row * ATLAS_CELL, gl.RGBA, gl.UNSIGNED_BYTE, scratch);
+ return true;
}
- function loadImage(url) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.decoding = "async";
- img.onload = () => resolve(img);
- img.onerror = () => reject(new Error(`icon load failed: ${url}`));
- img.src = url;
+ async function loadImageSource(url) {
+ const resolved = resolveVisualiserAssetUrl(url);
+ if (typeof createImageBitmap === "function") {
+ try {
+ const res = await fetch(resolved);
+ if (!res.ok) throw new Error(`icon fetch ${res.status}`);
+ const blob = await res.blob();
+ return await createImageBitmap(blob);
+ } catch {
+ // Fall through to HTMLImageElement.
+ }
+ }
+ const img = await new Promise((resolve, reject) => {
+ const el = new Image();
+ el.decoding = "sync";
+ el.onload = () => resolve(el);
+ el.onerror = () => reject(new Error(`icon load failed: ${resolved}`));
+ el.src = resolved;
});
+ if (typeof img.decode === "function") {
+ try {
+ await img.decode();
+ } catch {
+ // decode() can reject for already-decoded bitmaps
+ }
+ }
+ return img;
}
/**
@@ -253,9 +323,21 @@ function createIconAtlas(gl) {
if (pending.has(url)) return pending.get(url);
const slot = allocSlot();
if (slot == null) return null;
- const work = loadImage(url)
+ const work = loadImageSource(url)
.then((img) => {
- paintSlot(slot, img);
+ const ok = paintSlot(slot, img);
+ if (typeof img.close === "function") {
+ try {
+ img.close();
+ } catch {
+ /* ignore */
+ }
+ }
+ if (!ok) {
+ freeSlots.push(slot);
+ pending.delete(url);
+ return null;
+ }
urlToSlot.set(url, slot);
pending.delete(url);
return slot;
@@ -350,6 +432,19 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
let edgeVertexCount = 0;
let edgeScratch = new Float32Array(0);
+ /** @type {HTMLCanvasElement|null} */
+ let labelCanvas = null;
+ /** @type {CanvasRenderingContext2D|null} */
+ let labelCtx = null;
+ if (typeof document !== "undefined" && canvas?.parentElement) {
+ labelCanvas = document.createElement("canvas");
+ labelCanvas.className = "network-webgl-labels";
+ labelCanvas.style.cssText =
+ "position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;";
+ canvas.parentElement.appendChild(labelCanvas);
+ labelCtx = labelCanvas.getContext("2d");
+ }
+
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
@@ -361,6 +456,10 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
canvas.width = bw;
canvas.height = bh;
}
+ if (labelCanvas && (labelCanvas.width !== bw || labelCanvas.height !== bh)) {
+ labelCanvas.width = bw;
+ labelCanvas.height = bh;
+ }
gl.viewport(0, 0, canvas.width, canvas.height);
return { width: cssW, height: cssH };
}
@@ -370,12 +469,14 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
* @param {Float32Array} edges packed EDGE_STRIDE
* @param {{x:number,y:number,zoom:number}} camera
* @param {boolean} dark
+ * @param {{x:number,y:number,size:number,text:string}[]} [labels]
*/
- function draw(nodes, edges, camera, dark) {
+ function draw(nodes, edges, camera, dark, labels) {
const size = resize();
const camX = camera?.x ?? 0;
const camY = camera?.y ?? 0;
const zoom = camera?.zoom > 0 ? camera.zoom : 1;
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (dark) {
gl.clearColor(0.035, 0.035, 0.04, 1);
@@ -448,6 +549,32 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
gl.bindVertexArray(null);
}
+ if (labelCtx && labelCanvas) {
+ labelCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ labelCtx.clearRect(0, 0, cssW, cssH);
+ if (zoom >= 0.45 && Array.isArray(labels) && labels.length > 0) {
+ labelCtx.textAlign = "center";
+ labelCtx.textBaseline = "top";
+ labelCtx.font = "600 11px ui-sans-serif, system-ui, sans-serif";
+ const fill = dark ? "#f4f4f5" : "#18181b";
+ const stroke = dark ? "rgba(9,9,11,0.85)" : "rgba(255,255,255,0.9)";
+ for (const lab of labels) {
+ if (!lab?.text) continue;
+ const sx = (lab.x - camX) * zoom + cssW * 0.5;
+ const sy = (lab.y - camY) * zoom + cssH * 0.5;
+ if (sx < -40 || sy < -20 || sx > cssW + 40 || sy > cssH + 20) continue;
+ const r = Math.max(lab.size || 10, 6) * zoom;
+ const tx = sx;
+ const ty = sy + r + 3;
+ labelCtx.lineWidth = 3;
+ labelCtx.strokeStyle = stroke;
+ labelCtx.fillStyle = fill;
+ labelCtx.strokeText(lab.text, tx, ty);
+ labelCtx.fillText(lab.text, tx, ty);
+ }
+ }
+ }
+
return size;
}
@@ -460,6 +587,11 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
gl.deleteVertexArray(edgeVao);
gl.deleteProgram(nodeProg);
gl.deleteProgram(edgeProg);
+ if (labelCanvas?.parentElement) {
+ labelCanvas.parentElement.removeChild(labelCanvas);
+ }
+ labelCanvas = null;
+ labelCtx = null;
}
return {
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
index 113fec18..feae91ff 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
@@ -20,6 +20,14 @@ export const KIND_IFACE_OFF = 2;
export const KIND_PEER = 3;
export const KIND_DISCOVERED = 4;
+const DEFAULT_ICON_BY_KIND = {
+ [KIND_ME]: "/assets/images/reticulum_logo_512.png",
+ [KIND_IFACE_ON]: "/assets/images/network-visualiser/interface_connected.png",
+ [KIND_IFACE_OFF]: "/assets/images/network-visualiser/interface_disconnected.png",
+ [KIND_PEER]: "/assets/images/network-visualiser/user.png",
+ [KIND_DISCOVERED]: "/assets/images/network-visualiser/interface_connected.png",
+};
+
/**
* Distance between two CSS points.
* @param {{x:number,y:number}} a
@@ -113,12 +121,19 @@ function kindForNode(node) {
function sizeForNode(node, kind) {
const s = Number(node?.size);
if (Number.isFinite(s) && s > 0) {
- return Math.max(6, Math.min(28, s * 0.35));
+ return Math.max(10, Math.min(34, s * 0.55));
+ }
+ if (kind === KIND_ME) return 26;
+ if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 18;
+ if (kind === KIND_DISCOVERED) return 14;
+ return 16;
+}
+
+function imageForNode(node, kind) {
+ if (typeof node?.image === "string" && node.image) {
+ return node.image;
}
- if (kind === KIND_ME) return 18;
- if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 12;
- if (kind === KIND_DISCOVERED) return 9;
- return 10;
+ return DEFAULT_ICON_BY_KIND[kind] || null;
}
/**
@@ -195,6 +210,8 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
const indexById = new Map();
/** @type {(string|null)[]} */
let imageByIndex = [];
+ /** @type {(string|null)[]} */
+ let labelByIndex = [];
/** @type {{useTex:number,u:number,v:number}[]} */
let texMeta = [];
let drawNodeScratch = new Float32Array(0);
@@ -256,10 +273,12 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
metaById.clear();
indexById.clear();
imageByIndex = [];
+ labelByIndex = [];
let idx = 0;
for (const n of graphNodes || []) {
if (!n?.id) continue;
const id = String(n.id);
+ const kind = kindForNode(n);
metaById.set(id, {
id,
label: n.label || "",
@@ -268,7 +287,8 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
announce: n._announce || null,
});
indexById.set(id, idx);
- imageByIndex[idx] = typeof n.image === "string" && n.image ? n.image : null;
+ imageByIndex[idx] = imageForNode(n, kind);
+ labelByIndex[idx] = typeof n.label === "string" && n.label ? n.label : null;
idx += 1;
}
const size = renderer.resize();
@@ -347,7 +367,21 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
drawNodeScratch = new Float32Array(need);
}
const drawNodes = mergeSceneNodesWithTextures(buf.nodes, texMeta, drawNodeScratch);
- const size = renderer.draw(drawNodes, buf.edges, camera, dark);
+ const labels = [];
+ if (camera.zoom >= 0.45) {
+ for (let i = 0; i < sceneCount; i++) {
+ const text = labelByIndex[i];
+ if (!text) continue;
+ const o = i * SCENE_NODE_STRIDE;
+ labels.push({
+ x: buf.nodes[o],
+ y: buf.nodes[o + 1],
+ size: buf.nodes[o + 2],
+ text,
+ });
+ }
+ }
+ const size = renderer.draw(drawNodes, buf.edges, camera, dark, labels);
callScene("meshchatxVisualiserSceneResize", size.width, size.height);
nodeCount = buf.nodeCount || nodeCount;
edgeCount = buf.edgeCount || edgeCount;
@@ -506,6 +540,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
metaById.clear();
indexById.clear();
imageByIndex = [];
+ labelByIndex = [];
texMeta = [];
}
diff --git a/pytest.ini b/pytest.ini
index 252c03a2..94e140b2 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -7,6 +7,8 @@ markers =
integration: optional tests (live network, subprocess Reticulum, etc.)
lxst_real: tests that require the real LXST Telephone class
long_running: multi-minute soak tests (set MESHCHAT_LONG_TEST_SECONDS; see test_long_running_stress.py)
+ eect: Extended Edge Case Tester scenarios (seeded adversity packs)
+ live_validation: Live Validation ladder (L0 always; L2+ needs MESHCHAT_LIVE_VALIDATION=1)
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
filterwarnings =
diff --git a/tests/backend/eect/__init__.py b/tests/backend/eect/__init__.py
new file mode 100644
index 00000000..efa0b1d2
--- /dev/null
+++ b/tests/backend/eect/__init__.py
@@ -0,0 +1,6 @@
+# SPDX-License-Identifier: 0BSD
+"""Extended Edge Case Tester (EECT) and Live Validation (LV).
+
+EECT packs force adversarial timing, identity, auth, hostility, and scarcity
+under controlled seeds. LV proves this install is alive (ladder L0-L3).
+"""
diff --git a/tests/backend/eect/asserts.py b/tests/backend/eect/asserts.py
new file mode 100644
index 00000000..95e96a9c
--- /dev/null
+++ b/tests/backend/eect/asserts.py
@@ -0,0 +1,47 @@
+# SPDX-License-Identifier: 0BSD
+"""Shared oracles for EECT packs."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def assert_no_unexpected_http_500(status: int, body: Any = None) -> None:
+ """EECT HTTP oracle: unexpected 500s fail; 4xx/503 are recoverable."""
+ if status == 500:
+ raise AssertionError(f"unexpected HTTP 500 body={body!r}")
+
+
+def assert_recoverable_missing_path(exc: BaseException) -> None:
+ """Direct send without path must surface TimeoutError, not an opaque crash."""
+ assert isinstance(exc, TimeoutError), f"expected TimeoutError, got {type(exc)}"
+ msg = str(exc).lower()
+ assert "path" in msg, f"TimeoutError should mention path: {exc}"
+
+
+def assert_identity_paths_isolated(path_a: str, path_b: str) -> None:
+ assert path_a != path_b
+ assert "identities" in path_a.replace("\\", "/")
+ assert "identities" in path_b.replace("\\", "/")
+ assert path_a.rstrip("/\\").endswith("database.db") or "database.db" in path_a
+ assert path_b.rstrip("/\\").endswith("database.db") or "database.db" in path_b
+
+
+def assert_preview_capped(content: str | None, max_chars: int = 240) -> None:
+ if content is None:
+ return
+ assert len(content) <= max_chars, f"preview len {len(content)} > {max_chars}"
+
+
+def assert_diagnostic_text_redacted(text: str) -> None:
+ """Oracle: diagnostic dumps must not keep raw absolute paths or full 32-byte hex hashes."""
+ lower = text.lower()
+ assert "/tmp/" not in lower
+ assert "\\users\\" not in lower
+ assert "/home/" not in lower
+ assert "/users/" not in lower
+ # Full 32-byte hex destination/identity hash (64 hex chars)
+ import re
+
+ full_hashes = re.findall(r"(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])", lower)
+ assert not full_hashes, f"unredacted full hashes remain: {full_hashes[:3]}"
diff --git a/tests/backend/eect/catalog.py b/tests/backend/eect/catalog.py
new file mode 100644
index 00000000..8e7711ac
--- /dev/null
+++ b/tests/backend/eect/catalog.py
@@ -0,0 +1,164 @@
+# SPDX-License-Identifier: 0BSD
+"""Scenario registry for Extended Edge Case Tester packs."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+Taxonomy = Literal[
+ "logic",
+ "timing",
+ "identity_leak",
+ "security_surface",
+ "resource",
+ "live_env",
+]
+
+
+@dataclass(frozen=True)
+class Scenario:
+ """One EECT scenario mapped to a Zen gate and failure taxonomy."""
+
+ id: str
+ pack: str
+ gate: str
+ taxonomy: Taxonomy
+ summary: str
+ ci: bool = True
+
+
+SCENARIOS: tuple[Scenario, ...] = (
+ Scenario(
+ id="identity.switch.teardown_clears_context",
+ pack="IdentitySwitchPack",
+ gate="gate6-identity-context",
+ taxonomy="identity_leak",
+ summary="hotswap tears down prior context and drops it from contexts map",
+ ),
+ Scenario(
+ id="identity.switch.storage_paths_isolated",
+ pack="IdentitySwitchPack",
+ gate="gate6-identity-context",
+ taxonomy="identity_leak",
+ summary="identity A and B use distinct database.db paths under identities/",
+ ),
+ Scenario(
+ id="path.direct.blocks_when_unavailable",
+ pack="MissingPathPack",
+ gate="gate4-scarcity-async",
+ taxonomy="timing",
+ summary="direct send raises recoverable TimeoutError and never outbound",
+ ),
+ Scenario(
+ id="path.propagated.skips_await",
+ pack="MissingPathPack",
+ gate="gate4-scarcity-async",
+ taxonomy="timing",
+ summary="propagated delivery does not await peer path",
+ ),
+ Scenario(
+ id="hostile.favourites.layout_fuzz",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="normalize_favourites_layout never crashes on hostile blobs",
+ ),
+ Scenario(
+ id="hostile.bug_report.redacts_secrets",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="bug report preview redacts paths and full destination hashes",
+ ),
+ Scenario(
+ id="hostile.overlay.format_char_fixed",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="Micron/Nomad strip fixed even when hidden with ZWSP or soft hyphen",
+ ),
+ Scenario(
+ id="hostile.favourites.null_bytes",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="favourites layout rejects NUL in section ids and hashes",
+ ),
+ Scenario(
+ id="hostile.url.decimal_link_local",
+ pack="HostileMediumPack",
+ gate="gate3-hostile-medium",
+ taxonomy="security_surface",
+ summary="LibreTranslate URL guard rejects decimal/hex link-local SSRF forms",
+ ),
+ Scenario(
+ id="scarcity.conversation.preview_capped",
+ pack="ScarcityPack",
+ gate="gate4-scarcity-async",
+ taxonomy="resource",
+ summary="conversation list content preview stays within 240 chars",
+ ),
+ Scenario(
+ id="scarcity.conversation.fields_slim",
+ pack="ScarcityPack",
+ gate="gate4-scarcity-async",
+ taxonomy="resource",
+ summary="conversation list omits heavy fields blobs",
+ ),
+ Scenario(
+ id="auth.csrf.mutating_without_token",
+ pack="AuthSurfacePack",
+ gate="gate6-auth-surface",
+ taxonomy="security_surface",
+ summary="sampled mutating HTTP routes reject missing CSRF",
+ ),
+ Scenario(
+ id="auth.csrf.mutating_with_token",
+ pack="AuthSurfacePack",
+ gate="gate6-auth-surface",
+ taxonomy="security_surface",
+ summary="sampled mutating HTTP routes accept valid CSRF",
+ ),
+ Scenario(
+ id="lv.l0.imports_sqlite_unicode",
+ pack="LiveValidation",
+ gate="gate0-intent",
+ taxonomy="live_env",
+ summary="L0 self-check imports, sqlite, unicode path",
+ ),
+ Scenario(
+ id="lv.l1.status_and_csrf_reject",
+ pack="LiveValidation",
+ gate="gate6-auth-surface",
+ taxonomy="live_env",
+ summary="L1 status OK and unauth mutating POST rejected",
+ ),
+ Scenario(
+ id="lv.l2.rns_subprocess",
+ pack="LiveValidation",
+ gate="gate0-intent",
+ taxonomy="live_env",
+ summary="L2 RNS start/exit in isolated subprocess",
+ ci=False,
+ ),
+ Scenario(
+ id="lv.l3.loopback_tcp",
+ pack="LiveValidation",
+ gate="gate0-intent",
+ taxonomy="live_env",
+ summary="L3 loopback TCP bind prove-alive",
+ ci=False,
+ ),
+)
+
+
+def get_scenario(scenario_id: str) -> Scenario:
+ for scenario in SCENARIOS:
+ if scenario.id == scenario_id:
+ return scenario
+ raise KeyError(f"unknown EECT scenario: {scenario_id}")
+
+
+def scenarios_for_pack(pack: str) -> tuple[Scenario, ...]:
+ return tuple(s for s in SCENARIOS if s.pack == pack)
diff --git a/tests/backend/eect/harness.py b/tests/backend/eect/harness.py
new file mode 100644
index 00000000..469a6b36
--- /dev/null
+++ b/tests/backend/eect/harness.py
@@ -0,0 +1,51 @@
+# SPDX-License-Identifier: 0BSD
+"""Seeded harness and failure banner for EECT scenarios."""
+
+from __future__ import annotations
+
+import os
+import random
+from contextlib import contextmanager
+from typing import Iterator
+
+from tests.backend.eect.catalog import Scenario, get_scenario
+
+
+def resolve_seed(explicit: int | None = None) -> int:
+ """Return replay seed from arg, MESHCHAT_EECT_SEED, or a fresh random seed."""
+ if explicit is not None:
+ return int(explicit) & 0xFFFFFFFF
+ raw = os.environ.get("MESHCHAT_EECT_SEED")
+ if raw is not None and str(raw).strip() != "":
+ return int(str(raw).strip(), 0) & 0xFFFFFFFF
+ return random.SystemRandom().randint(0, 0xFFFFFFFF)
+
+
+def format_failure_banner(scenario: Scenario, seed: int, detail: str = "") -> str:
+ lines = [
+ "EECT FAILURE",
+ f" scenario_id: {scenario.id}",
+ f" pack: {scenario.pack}",
+ f" gate: {scenario.gate}",
+ f" taxonomy: {scenario.taxonomy}",
+ f" seed: {seed}",
+ f" replay: MESHCHAT_EECT_SEED={seed}",
+ ]
+ if detail:
+ lines.append(f" detail: {detail}")
+ return "\n".join(lines)
+
+
+@contextmanager
+def eect_scenario(
+ scenario_id: str, seed: int | None = None
+) -> Iterator[tuple[Scenario, int, random.Random]]:
+ """Bind scenario + seeded RNG. On assert failure, append banner to the message."""
+ scenario = get_scenario(scenario_id)
+ resolved = resolve_seed(seed)
+ rng = random.Random(resolved)
+ try:
+ yield scenario, resolved, rng
+ except AssertionError as exc:
+ banner = format_failure_banner(scenario, resolved, detail=str(exc))
+ raise AssertionError(f"{banner}\n\n{exc}") from None
diff --git a/tests/backend/eect/live/__init__.py b/tests/backend/eect/live/__init__.py
new file mode 100644
index 00000000..e45200eb
--- /dev/null
+++ b/tests/backend/eect/live/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: 0BSD
+"""Live Validation ladder tests."""
diff --git a/tests/backend/eect/live/test_lv_ladder.py b/tests/backend/eect/live/test_lv_ladder.py
new file mode 100644
index 00000000..117ff561
--- /dev/null
+++ b/tests/backend/eect/live/test_lv_ladder.py
@@ -0,0 +1,114 @@
+# SPDX-License-Identifier: 0BSD
+"""Live Validation ladder L0-L3 (acceptance for this install)."""
+
+from __future__ import annotations
+
+import os
+import secrets
+import subprocess
+import sys
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+from aiohttp_session import setup as setup_session
+
+from meshchatx.src.backend import self_check
+from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers
+from tests.backend.eect.harness import eect_scenario
+from tests.backend.support.test_temp_dir import subprocess_test_env
+
+pytestmark = pytest.mark.live_validation
+
+_LIVE = (
+ os.environ.get("MESHCHAT_LIVE_VALIDATION") == "1"
+ or os.environ.get(
+ "MESHCHAT_LIVE_RETICULUM",
+ )
+ == "1"
+)
+
+
+def test_lv_l0_imports_sqlite_unicode(tmp_path):
+ with eect_scenario("lv.l0.imports_sqlite_unicode") as (_s, _seed, _rng):
+ assert self_check.check_critical_imports()["status"] == "ok"
+ assert self_check.check_python_runtime()["status"] == "ok"
+ assert self_check.check_sqlite_roundtrip(str(tmp_path))["status"] == "ok"
+ assert self_check.check_unicode_path(str(tmp_path))["status"] == "ok"
+ assert self_check.check_temp_filesystem()["status"] == "ok"
+
+
+def _make_aio_app(mock_app, use_https: bool = False):
+ mock_app.session_secret_key = secrets.token_urlsafe(32)
+ mock_app.listen_host = "127.0.0.1"
+ mock_app.listen_port = 8000
+ mock_app.use_https = use_https
+ mock_app.landlock_active = False
+ routes = web.RouteTableDef()
+ middlewares = mock_app._define_routes(routes)
+ aio_app = web.Application()
+ setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
+ extend_meshchat_middlewares(aio_app, middlewares)
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("require_loopback_tcp")
+async def test_lv_l1_status_and_csrf_reject(mock_app, monkeypatch):
+ with eect_scenario("lv.l1.status_and_csrf_reject") as (_s, _seed, _rng):
+ monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ aio_app = _make_aio_app(mock_app)
+ async with TestClient(TestServer(aio_app)) as client:
+ status = await client.get("/api/v1/status")
+ assert status.status == 200
+ body = await status.json()
+ assert body.get("status") in ("ok", "starting", "failed")
+
+ blocked = await client.patch(
+ "/api/v1/server/security",
+ json={"web_ui_ip_allowlist": ""},
+ )
+ assert blocked.status == 403
+
+ headers = await fetch_api_csrf_headers(client)
+ ok = await client.patch(
+ "/api/v1/server/security",
+ json={"web_ui_ip_allowlist": ""},
+ headers=headers,
+ )
+ assert ok.status == 200
+
+
+@pytest.mark.skipif(not _LIVE, reason="Set MESHCHAT_LIVE_VALIDATION=1 for LV L2+")
+@pytest.mark.integration
+def test_lv_l2_rns_subprocess():
+ with eect_scenario("lv.l2.rns_subprocess") as (_s, _seed, _rng):
+ script = r"""
+import tempfile
+import RNS
+
+tmpdir = tempfile.mkdtemp(prefix="meshchat_lv_l2_")
+try:
+ RNS.Reticulum(configdir=tmpdir, loglevel=RNS.LOG_ERROR)
+finally:
+ RNS.exit(0)
+"""
+ result = subprocess.run(
+ [sys.executable, "-c", script],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ env=subprocess_test_env(),
+ )
+ assert result.returncode == 0, result.stderr + result.stdout
+
+
+@pytest.mark.skipif(not _LIVE, reason="Set MESHCHAT_LIVE_VALIDATION=1 for LV L2+")
+@pytest.mark.integration
+@pytest.mark.usefixtures("require_loopback_tcp")
+def test_lv_l3_loopback_tcp():
+ with eect_scenario("lv.l3.loopback_tcp") as (_s, _seed, _rng):
+ result = self_check.check_loopback_tcp()
+ assert result["status"] == "ok", result.get("reason")
diff --git a/tests/backend/eect/packs/__init__.py b/tests/backend/eect/packs/__init__.py
new file mode 100644
index 00000000..33e5e3a4
--- /dev/null
+++ b/tests/backend/eect/packs/__init__.py
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: 0BSD
+"""EECT pack tests (pytest collects test_*.py under this tree)."""
diff --git a/tests/backend/eect/packs/test_auth_surface_pack.py b/tests/backend/eect/packs/test_auth_surface_pack.py
new file mode 100644
index 00000000..3e9188c3
--- /dev/null
+++ b/tests/backend/eect/packs/test_auth_surface_pack.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: 0BSD
+"""AuthSurfacePack: mutating HTTP without CSRF must die; with CSRF must pass."""
+
+from __future__ import annotations
+
+import secrets
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+from aiohttp_session import setup as setup_session
+
+from tests.backend.conftest import extend_meshchat_middlewares, fetch_api_csrf_headers
+from tests.backend.eect.asserts import assert_no_unexpected_http_500
+from tests.backend.eect.harness import eect_scenario
+
+pytestmark = [
+ pytest.mark.eect,
+ pytest.mark.usefixtures("require_loopback_tcp"),
+]
+
+# Curated mutating surfaces that accept empty/minimal bodies without deep state.
+_MUTATING_SAMPLES = (
+ ("PATCH", "/api/v1/server/security", {"web_ui_ip_allowlist": ""}),
+ ("POST", "/api/v1/app/tutorial/seen", {}),
+ ("POST", "/api/v1/app/changelog/seen", {"version": "999.999.999"}),
+)
+
+
+def _make_aio_app(mock_app, use_https: bool = False):
+ mock_app.session_secret_key = secrets.token_urlsafe(32)
+ mock_app.listen_host = "127.0.0.1"
+ mock_app.listen_port = 8000
+ mock_app.use_https = use_https
+ mock_app.landlock_active = False
+ routes = web.RouteTableDef()
+ middlewares = mock_app._define_routes(routes)
+ aio_app = web.Application()
+ setup_session(aio_app, mock_app._encrypted_cookie_storage(use_https))
+ extend_meshchat_middlewares(aio_app, middlewares)
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+@pytest.mark.asyncio
+async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
+ with eect_scenario("auth.csrf.mutating_without_token") as (_s, _seed, rng):
+ monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ aio_app = _make_aio_app(mock_app)
+ samples = list(_MUTATING_SAMPLES)
+ rng.shuffle(samples)
+ async with TestClient(TestServer(aio_app)) as client:
+ for method, path, body in samples:
+ if method == "PATCH":
+ resp = await client.patch(path, json=body)
+ else:
+ resp = await client.post(path, json=body)
+ assert_no_unexpected_http_500(resp.status, await resp.text())
+ assert resp.status == 403, (
+ f"{method} {path} expected 403 got {resp.status}"
+ )
+
+
+@pytest.mark.asyncio
+async def test_eect_mutating_with_csrf_accepted(mock_app, monkeypatch):
+ with eect_scenario("auth.csrf.mutating_with_token") as (_s, _seed, rng):
+ monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
+ aio_app = _make_aio_app(mock_app)
+ samples = list(_MUTATING_SAMPLES)
+ rng.shuffle(samples)
+ async with TestClient(TestServer(aio_app)) as client:
+ headers = await fetch_api_csrf_headers(client)
+ for method, path, body in samples:
+ if method == "PATCH":
+ resp = await client.patch(path, json=body, headers=headers)
+ else:
+ resp = await client.post(path, json=body, headers=headers)
+ body_text = await resp.text()
+ assert_no_unexpected_http_500(resp.status, body_text)
+ assert resp.status != 403, f"{method} {path} still CSRF-blocked"
+ assert resp.status < 500
diff --git a/tests/backend/eect/packs/test_harness_acceptance.py b/tests/backend/eect/packs/test_harness_acceptance.py
new file mode 100644
index 00000000..477a5ff5
--- /dev/null
+++ b/tests/backend/eect/packs/test_harness_acceptance.py
@@ -0,0 +1,37 @@
+# SPDX-License-Identifier: 0BSD
+"""Acceptance-style harness checks for EECT failure banners."""
+
+from __future__ import annotations
+
+import pytest
+
+from tests.backend.eect.catalog import get_scenario
+from tests.backend.eect.harness import (
+ eect_scenario,
+ format_failure_banner,
+ resolve_seed,
+)
+
+pytestmark = pytest.mark.eect
+
+
+def test_failure_banner_includes_scenario_seed_gate():
+ scenario = get_scenario("hostile.bug_report.redacts_secrets")
+ banner = format_failure_banner(scenario, 42, detail="boom")
+ assert "scenario_id: hostile.bug_report.redacts_secrets" in banner
+ assert "gate: gate3-hostile-medium" in banner
+ assert "seed: 42" in banner
+ assert "MESHCHAT_EECT_SEED=42" in banner
+ assert "taxonomy: security_surface" in banner
+
+
+def test_eect_scenario_rewrites_assertion(monkeypatch):
+ monkeypatch.setenv("MESHCHAT_EECT_SEED", "7")
+ assert resolve_seed() == 7
+ with pytest.raises(AssertionError) as caught:
+ with eect_scenario("path.direct.blocks_when_unavailable"):
+ assert False, "forced"
+ msg = str(caught.value)
+ assert "EECT FAILURE" in msg
+ assert "path.direct.blocks_when_unavailable" in msg
+ assert "MESHCHAT_EECT_SEED=7" in msg
diff --git a/tests/backend/eect/packs/test_hostile_medium_pack.py b/tests/backend/eect/packs/test_hostile_medium_pack.py
new file mode 100644
index 00000000..2acaec31
--- /dev/null
+++ b/tests/backend/eect/packs/test_hostile_medium_pack.py
@@ -0,0 +1,155 @@
+# SPDX-License-Identifier: 0BSD
+"""HostileMediumPack: hostile payloads and diagnostic redaction."""
+
+from __future__ import annotations
+
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.bug_report_manager import BugReportManager
+from meshchatx.src.backend.favourites_layout import (
+ MAX_SECTIONS,
+ normalize_favourites_layout,
+)
+from meshchatx.src.backend.log_redaction import REDACTED, redact_diagnostic_text
+from tests.backend.eect.asserts import assert_diagnostic_text_redacted
+from tests.backend.eect.harness import eect_scenario
+
+pytestmark = pytest.mark.eect
+
+
+@settings(deadline=None, max_examples=40, suppress_health_check=[HealthCheck.too_slow])
+@given(
+ raw=st.one_of(
+ st.none(),
+ st.integers(),
+ st.text(max_size=80),
+ st.lists(st.integers(), max_size=20),
+ st.dictionaries(
+ keys=st.text(min_size=0, max_size=40),
+ values=st.one_of(
+ st.none(),
+ st.booleans(),
+ st.integers(),
+ st.text(max_size=40),
+ st.lists(st.text(max_size=20), max_size=8),
+ ),
+ max_size=12,
+ ),
+ st.fixed_dictionaries(
+ {
+ "sections": st.lists(
+ st.fixed_dictionaries(
+ {
+ "id": st.one_of(
+ st.none(),
+ st.sampled_from(
+ ["__proto__", "constructor", "prototype", "ok", ""]
+ ),
+ st.text(min_size=0, max_size=80),
+ ),
+ "name": st.one_of(st.none(), st.text(max_size=200)),
+ "collapsed": st.one_of(
+ st.none(), st.booleans(), st.integers()
+ ),
+ },
+ ),
+ max_size=MAX_SECTIONS + 5,
+ ),
+ "sectionOrder": st.lists(st.text(max_size=40), max_size=20),
+ "favouritesBySection": st.dictionaries(
+ keys=st.text(max_size=40),
+ values=st.lists(st.text(max_size=80), max_size=30),
+ max_size=10,
+ ),
+ },
+ ),
+ ),
+)
+def test_eect_favourites_layout_fuzz_never_raises(raw):
+ with eect_scenario("hostile.favourites.layout_fuzz") as (_s, _seed, _rng):
+ out = normalize_favourites_layout(raw)
+ assert out is None or (
+ isinstance(out, dict)
+ and isinstance(out.get("sections"), list)
+ and isinstance(out.get("sectionOrder"), list)
+ and isinstance(out.get("favouritesBySection"), dict)
+ )
+
+
+def test_eect_bug_report_redacts_secrets(tmp_path):
+ with eect_scenario("hostile.bug_report.redacts_secrets") as (_s, _seed, _rng):
+ full_hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
+
+ class FakeLogs:
+ def get_logs(self, **_kwargs):
+ return [
+ {
+ "timestamp": 1.0,
+ "level": "ERROR",
+ "module": "meshchat",
+ "message": (
+ f"fail at /tmp/x for {full_hash} "
+ "user@example.com 203.0.113.9"
+ ),
+ },
+ ]
+
+ def get_total_count(self, **_kwargs):
+ return 1
+
+ class FakeDatabase:
+ debug_logs = FakeLogs()
+
+ class FakeApp:
+ database = FakeDatabase()
+ storage_dir = str(tmp_path)
+ current_context = None
+
+ manager = BugReportManager(FakeApp())
+ preview = manager.preview_report({"limit": 5})
+ assert_diagnostic_text_redacted(preview["log_text"])
+ assert "/tmp/x" not in preview["log_text"]
+ assert full_hash not in preview["log_text"]
+ assert "user@example.com" not in preview["log_text"]
+ assert "203.0.113.9" not in preview["log_text"]
+ assert REDACTED in preview["log_text"]
+
+
+def test_eect_redact_helper_preserves_short_hash_prefix():
+ short = "aabbccddeeff00112233445566778899"
+ out = redact_diagnostic_text(f"peer {short} ok")
+ assert short in out
+
+
+def test_eect_favourites_rejects_null_bytes():
+ with eect_scenario("hostile.favourites.null_bytes") as (_s, _seed, _rng):
+ layout = normalize_favourites_layout(
+ {
+ "sections": [
+ {"id": "bad\x00id", "name": "x"},
+ {"id": "ok", "name": "y"},
+ ],
+ "favouritesBySection": {"ok": ["a\x00b", "c" * 32]},
+ },
+ )
+ assert layout is not None
+ assert [s["id"] for s in layout["sections"]] == ["ok"]
+ assert layout["favouritesBySection"]["ok"] == ["c" * 32]
+
+
+def test_eect_rejects_decimal_hex_link_local_urls():
+ from meshchatx.src.backend.http_url_guard import (
+ UnsafeOutboundUrlError,
+ normalize_libretranslate_http_service_base,
+ )
+
+ with eect_scenario("hostile.url.decimal_link_local") as (_s, _seed, _rng):
+ for bad in (
+ "http://2852039166/",
+ "http://0xa9fea9fe/",
+ "http://169.254.169.254/",
+ ):
+ with pytest.raises(UnsafeOutboundUrlError):
+ normalize_libretranslate_http_service_base(bad)
diff --git a/tests/backend/eect/packs/test_identity_switch_pack.py b/tests/backend/eect/packs/test_identity_switch_pack.py
new file mode 100644
index 00000000..5a4158ee
--- /dev/null
+++ b/tests/backend/eect/packs/test_identity_switch_pack.py
@@ -0,0 +1,169 @@
+# SPDX-License-Identifier: 0BSD
+"""IdentitySwitchPack: teardown and path isolation under identity switch."""
+
+from __future__ import annotations
+
+import os
+from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.identity_context import IdentityContext
+from tests.backend.eect.asserts import assert_identity_paths_isolated
+from tests.backend.eect.harness import eect_scenario
+
+pytestmark = pytest.mark.eect
+
+
+@pytest.fixture
+def mock_rns(tmp_path):
+ real_identity_class = RNS.Identity
+
+ class MockIdentityClass(real_identity_class):
+ def __init__(self, *args, **kwargs):
+ self.hash = b"initial_hash_32_bytes_long_01234"
+ self.hexhash = self.hash.hex()
+
+ with ExitStack() as stack:
+ patches = [
+ patch("RNS.Reticulum"),
+ patch("RNS.Transport"),
+ patch("RNS.Identity", MockIdentityClass),
+ patch("threading.Thread"),
+ patch("meshchatx.src.backend.identity_context.Database"),
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
+ patch("meshchatx.src.backend.identity_context.MapManager"),
+ patch("meshchatx.src.backend.identity_context.DocsManager"),
+ patch("meshchatx.src.backend.identity_context.NomadNetworkManager"),
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
+ patch("meshchatx.src.backend.identity_context.RNCPHandler"),
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
+ patch("meshchatx.src.backend.identity_context.CommunityInterfacesManager"),
+ patch("LXMF.LXMRouter"),
+ patch("meshchatx.meshchat.IdentityContext"),
+ ]
+ mocks = {}
+ for p in patches:
+ attr_name = (
+ p.attribute if hasattr(p, "attribute") else p.target.split(".")[-1]
+ )
+ mocks[attr_name] = stack.enter_context(p)
+
+ mock_id_instance = MockIdentityClass()
+ mock_id_instance.get_private_key = MagicMock(
+ return_value=b"initial_private_key",
+ )
+ stack.enter_context(
+ patch.object(MockIdentityClass, "from_file", return_value=mock_id_instance),
+ )
+ stack.enter_context(
+ patch.object(MockIdentityClass, "recall", return_value=mock_id_instance),
+ )
+ stack.enter_context(
+ patch.object(
+ MockIdentityClass,
+ "from_bytes",
+ return_value=mock_id_instance,
+ ),
+ )
+ mock_config = mocks["ConfigManager"]
+ mock_config.return_value.display_name.get.return_value = "Test User"
+ yield {
+ "Identity": MockIdentityClass,
+ "id_instance": mock_id_instance,
+ "IdentityContext": mocks["IdentityContext"],
+ "tmp_path": tmp_path,
+ }
+
+
+@pytest.mark.asyncio
+async def test_eect_identity_switch_teardown_clears_context(mock_rns):
+ with eect_scenario("identity.switch.teardown_clears_context") as (
+ _scenario,
+ _seed,
+ _rng,
+ ):
+ temp_dir = str(mock_rns["tmp_path"])
+ app = ReticulumMeshChat(
+ identity=mock_rns["id_instance"],
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ old_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ old_ctx = MagicMock()
+ old_ctx.identity_hash = old_hash
+ app.current_context = old_ctx
+ app.contexts = {old_hash: old_ctx}
+
+ new_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ identity_dir = os.path.join(temp_dir, "identities", new_hash)
+ os.makedirs(identity_dir)
+ with open(os.path.join(identity_dir, "identity"), "wb") as f:
+ f.write(b"new_private_key")
+
+ new_id_instance = MagicMock()
+ new_id_instance.hash = bytes.fromhex(new_hash)
+ mock_rns["Identity"].from_file.return_value = new_id_instance
+
+ new_ctx = mock_rns["IdentityContext"].return_value
+ new_ctx.config.display_name.get.return_value = "New User"
+ new_ctx.identity_hash = new_hash
+
+ app.setup_identity = MagicMock(
+ side_effect=lambda _id: setattr(app, "current_context", new_ctx),
+ )
+ app.websocket_broadcast = AsyncMock()
+
+ with patch("meshchatx.meshchat.asyncio.sleep", new=AsyncMock()):
+ result = await app.hotswap_identity(new_hash)
+
+ assert result is True
+ old_ctx.teardown.assert_called_once()
+ assert old_hash not in app.contexts
+ assert app.current_context is new_ctx
+
+
+def test_eect_identity_storage_paths_isolated(tmp_path):
+ with eect_scenario("identity.switch.storage_paths_isolated") as (
+ _scenario,
+ _seed,
+ _rng,
+ ):
+ app = MagicMock()
+ app.storage_dir = str(tmp_path)
+ hash_a = "a" * 32
+ hash_b = "b" * 32
+
+ # Mirror IdentityContext path construction without full manager init.
+ path_a = os.path.join(app.storage_dir, "identities", hash_a, "database.db")
+ path_b = os.path.join(app.storage_dir, "identities", hash_b, "database.db")
+ assert_identity_paths_isolated(path_a, path_b)
+
+ # Confirm the live class still builds the same layout for hash inputs.
+ id_a = MagicMock()
+ id_a.hash = bytes.fromhex(hash_a)
+ with (
+ patch.object(IdentityContext, "__init__", lambda self, *_a, **_k: None),
+ ):
+ ctx = IdentityContext.__new__(IdentityContext)
+ ctx.app = app
+ ctx.identity = id_a
+ ctx.identity_hash = id_a.hash.hex()
+ ctx.storage_path = os.path.join(
+ app.storage_dir,
+ "identities",
+ ctx.identity_hash,
+ )
+ ctx.database_path = os.path.join(ctx.storage_path, "database.db")
+ assert ctx.database_path == path_a
+ assert hash_b not in ctx.database_path
diff --git a/tests/backend/eect/packs/test_missing_path_pack.py b/tests/backend/eect/packs/test_missing_path_pack.py
new file mode 100644
index 00000000..310b627d
--- /dev/null
+++ b/tests/backend/eect/packs/test_missing_path_pack.py
@@ -0,0 +1,96 @@
+# SPDX-License-Identifier: 0BSD
+"""MissingPathPack: recoverable path outcomes for direct vs propagated send."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome
+from tests.backend.eect.asserts import assert_recoverable_missing_path
+from tests.backend.eect.harness import eect_scenario
+
+pytestmark = pytest.mark.eect
+
+
+@pytest.fixture
+def send_app():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app.current_context = MagicMock()
+ app.config = MagicMock()
+ app.database = MagicMock()
+ app.reticulum = MagicMock()
+ app.message_router = MagicMock()
+ app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(True, "reused_valid_path", False),
+ )
+ app.get_current_icon_hash = MagicMock(return_value=None)
+ app.db_upsert_lxmf_message = MagicMock()
+ app.websocket_broadcast = AsyncMock()
+ app._is_contact = MagicMock(return_value=False)
+ app._convert_webm_opus_to_ogg = MagicMock(side_effect=lambda b: b)
+ app.handle_lxmf_message_progress = AsyncMock()
+ ctx = app.current_context
+ ctx.message_router = app.message_router
+ ctx.database = app.database
+ ctx.config = app.config
+ ctx.local_lxmf_destination = MagicMock()
+ ctx.local_lxmf_destination.hexhash = "local_hash"
+ ctx.forwarding_manager = None
+ return app
+
+
+@pytest.mark.asyncio
+async def test_eect_direct_blocks_when_path_unavailable(send_app):
+ with eect_scenario("path.direct.blocks_when_unavailable") as (_s, _seed, _rng):
+ destination_hash = "aa" * 16
+ send_app.recall_identity = MagicMock(return_value=MagicMock())
+ send_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ with pytest.raises(TimeoutError) as caught:
+ await send_app.send_message(
+ destination_hash=destination_hash,
+ content="hi",
+ delivery_method="direct",
+ )
+ assert_recoverable_missing_path(caught.value)
+ send_app.message_router.handle_outbound.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_eect_propagated_skips_path_await(send_app):
+ with eect_scenario("path.propagated.skips_await") as (_s, _seed, _rng):
+ destination_hash = "aa" * 16
+ send_app.recall_identity = MagicMock(return_value=MagicMock())
+ send_app._await_transport_path = AsyncMock(
+ return_value=OutboundPathOutcome(False, "new_path_requested", True),
+ )
+ send_app.config.auto_send_failed_messages_to_propagation_node.get.return_value = False
+ send_app.config.include_display_name_with_message.get.return_value = False
+ send_app.config.include_icon_with_message.get.return_value = False
+ send_app.config.include_signature_with_message.get.return_value = False
+ mock_msg = MagicMock()
+ mock_msg.hash = b"\x01" * 16
+ mock_msg.fields = {}
+ with (
+ patch("meshchatx.meshchat.RNS.Destination", return_value=MagicMock()),
+ patch("meshchatx.meshchat.LXMF.LXMessage", return_value=mock_msg),
+ patch(
+ "meshchatx.meshchat.RNS.Identity.current_ratchet_id", return_value=None
+ ),
+ patch(
+ "meshchatx.meshchat.convert_lxmf_message_to_dict",
+ return_value={"hash": "01" * 16, "state": "outbound"},
+ ),
+ ):
+ result = await send_app.send_message(
+ destination_hash=destination_hash,
+ content="hi",
+ delivery_method="propagated",
+ )
+ assert result is mock_msg
+ send_app.message_router.handle_outbound.assert_called_once()
+ send_app._await_transport_path.assert_not_called()
diff --git a/tests/backend/eect/packs/test_scarcity_pack.py b/tests/backend/eect/packs/test_scarcity_pack.py
new file mode 100644
index 00000000..559193a1
--- /dev/null
+++ b/tests/backend/eect/packs/test_scarcity_pack.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: 0BSD
+"""ScarcityPack: conversation list stays slim under large payloads."""
+
+from __future__ import annotations
+
+import secrets
+import tempfile
+import time
+
+import pytest
+
+from meshchatx.src.backend.database import Database
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.message_handler import MessageHandler
+from tests.backend.eect.asserts import assert_preview_capped
+from tests.backend.eect.harness import eect_scenario
+
+pytestmark = pytest.mark.eect
+
+
+def _message(peer_hash, i, *, fields="{}", content="hello"):
+ return {
+ "hash": secrets.token_hex(16),
+ "source_hash": peer_hash,
+ "destination_hash": "localhashlocalhashlocalhashlo12",
+ "peer_hash": peer_hash,
+ "state": "delivered",
+ "progress": 1.0,
+ "is_incoming": 1,
+ "method": "direct",
+ "delivery_attempts": 1,
+ "next_delivery_attempt_at": None,
+ "title": f"t{i}",
+ "content": content,
+ "fields": fields,
+ "timestamp": time.time() - i,
+ "rssi": -50,
+ "snr": 5.0,
+ "quality": 3,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ }
+
+
+@pytest.fixture
+def handler_db():
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ tmp = tempfile.TemporaryDirectory()
+ db = Database(f"{tmp.name}/database.db")
+ db.initialize()
+ handler = MessageHandler(db)
+ yield handler, db
+ db.close()
+ if DatabaseProvider._instance is not None:
+ DatabaseProvider._instance.close_all()
+ DatabaseProvider._instance = None
+ tmp.cleanup()
+
+
+def test_eect_conversation_preview_capped(handler_db):
+ with eect_scenario("scarcity.conversation.preview_capped") as (_s, _seed, rng):
+ handler, db = handler_db
+ peer = secrets.token_hex(16)
+ long_content = "x" * (MessageHandler._CONVERSATION_CONTENT_PREVIEW_CHARS + 500)
+ # Sprinkle a few shorter messages so list still forms.
+ for i in range(3):
+ content = long_content if i == 0 else ("y" * (20 + rng.randint(0, 40)))
+ db.messages.upsert_lxmf_message(_message(peer, i, content=content))
+ rows = handler.get_conversations("local", limit=10)
+ assert rows
+ for row in rows:
+ assert_preview_capped(
+ dict(row).get("content"),
+ MessageHandler._CONVERSATION_CONTENT_PREVIEW_CHARS,
+ )
+
+
+def test_eect_conversation_list_omits_fields(handler_db):
+ with eect_scenario("scarcity.conversation.fields_slim") as (_s, _seed, _rng):
+ handler, db = handler_db
+ peer = secrets.token_hex(16)
+ big_b64 = "C" * 90000
+ db.messages.upsert_lxmf_message(
+ _message(
+ peer,
+ 0,
+ fields={
+ "image": {"image_type": "png", "image_bytes": big_b64},
+ },
+ content="",
+ ),
+ )
+ rows = handler.get_conversations("local", limit=10)
+ assert rows
+ row = dict(rows[0])
+ assert "fields" not in row
+ assert row.get("has_image") == 1
diff --git a/tests/backend/test_bug_report_manager.py b/tests/backend/test_bug_report_manager.py
index a24aa745..40dc639c 100644
--- a/tests/backend/test_bug_report_manager.py
+++ b/tests/backend/test_bug_report_manager.py
@@ -16,6 +16,8 @@ def _fake_app(tmp_path):
def test_preview_report_uses_database_logs(tmp_path):
+ full_hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
+
class FakeLogs:
def get_logs(self, **_kwargs):
return [
@@ -23,7 +25,7 @@ def test_preview_report_uses_database_logs(tmp_path):
"timestamp": 1.0,
"level": "ERROR",
"module": "meshchat",
- "message": "fail at /tmp/x for aabbccddeeff00112233445566778899",
+ "message": f"fail at /tmp/x for {full_hash}",
},
]
@@ -41,8 +43,9 @@ def test_preview_report_uses_database_logs(tmp_path):
manager = BugReportManager(FakeApp())
preview = manager.preview_report({"limit": 5})
assert preview["line_count"] == 1
- assert "/tmp/x" in preview["log_text"]
- assert "aabbccddeeff00112233445566778899" in preview["log_text"]
+ assert "/tmp/x" not in preview["log_text"]
+ assert full_hash not in preview["log_text"]
+ assert "[redacted]" in preview["log_text"]
assert preview["chars"] > 0
diff --git a/tests/backend/test_favourites_layout.py b/tests/backend/test_favourites_layout.py
index 21b732a0..bff9eaf3 100644
--- a/tests/backend/test_favourites_layout.py
+++ b/tests/backend/test_favourites_layout.py
@@ -134,6 +134,28 @@ def test_layout_payload_too_large():
assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES + 1) is True
assert layout_payload_too_large(MAX_LAYOUT_JSON_BYTES) is False
assert layout_payload_too_large("nope") is False
+ assert layout_payload_too_large(-1) is True
+ assert layout_payload_too_large("-5") is True
+
+
+def test_normalize_rejects_null_bytes_in_ids_and_hashes():
+ layout = normalize_favourites_layout(
+ {
+ "sections": [
+ {"id": "ok\x00evil", "name": "bad"},
+ {"id": "good", "name": "Good"},
+ ],
+ "sectionOrder": ["ok\x00evil", "good"],
+ "favouritesBySection": {
+ "good": ["ab\x00cd", "aabbccddeeff00112233445566778899"],
+ },
+ },
+ )
+ assert layout is not None
+ assert [s["id"] for s in layout["sections"]] == ["good"]
+ assert layout["favouritesBySection"]["good"] == [
+ "aabbccddeeff00112233445566778899",
+ ]
@given(
diff --git a/tests/backend/test_http_url_guard.py b/tests/backend/test_http_url_guard.py
index d53ca566..4ceded14 100644
--- a/tests/backend/test_http_url_guard.py
+++ b/tests/backend/test_http_url_guard.py
@@ -103,6 +103,10 @@ def test_normalize_libretranslate_private_and_loopback_ips():
"http://239.255.0.1:5000/",
"http://0.0.0.0/",
"http://240.0.0.1:1",
+ # Decimal / hex encodings of 169.254.169.254 (SSRF metadata bypass).
+ "http://2852039166/",
+ "http://0xa9fea9fe/",
+ "http://0xA9FEA9FE:80/",
],
)
def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad):
@@ -110,6 +114,16 @@ def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad):
normalize_libretranslate_http_service_base(bad)
+def test_normalize_libretranslate_allows_decimal_loopback():
+ # Loopback encodings remain allowed for local LibreTranslate.
+ assert normalize_libretranslate_http_service_base("http://2130706433:5000/") == (
+ "http://2130706433:5000"
+ )
+ assert normalize_libretranslate_http_service_base("http://0x7f000001/") == (
+ "http://0x7f000001"
+ )
+
+
@pytest.mark.parametrize(
"bad",
[
diff --git a/tests/backend/test_log_redaction.py b/tests/backend/test_log_redaction.py
new file mode 100644
index 00000000..5d58c1d3
--- /dev/null
+++ b/tests/backend/test_log_redaction.py
@@ -0,0 +1,48 @@
+# SPDX-License-Identifier: 0BSD
+"""Unit tests for diagnostic log redaction."""
+
+from meshchatx.src.backend.log_redaction import REDACTED, redact_diagnostic_text
+
+
+def test_redact_paths_hashes_email_ip():
+ full = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"
+ text = f"fail at /tmp/secret/db for {full} user@example.com from 203.0.113.9"
+ out = redact_diagnostic_text(text)
+ assert "/tmp/" not in out.lower()
+ assert full not in out
+ assert "user@example.com" not in out
+ assert "203.0.113.9" not in out
+ assert out.count(REDACTED) >= 3
+
+
+def test_redact_run_media_and_windows_drives():
+ out = redact_diagnostic_text(
+ "fail at /run/media/user1/projects/db and D:\\Users\\user1\\AppData\\id",
+ )
+ assert "/run/media/" not in out.lower()
+ assert "D:\\Users" not in out
+ assert REDACTED in out
+
+
+def test_redact_pem_bearer_and_secret_assigns():
+ pem = "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END PRIVATE KEY-----"
+ out = redact_diagnostic_text(
+ f"{pem} Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb "
+ "alias_identity_private_key=YWJjZGVm",
+ )
+ assert "BEGIN PRIVATE KEY" not in out
+ assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in out
+ assert "YWJjZGVm" not in out
+ assert "Bearer" in out
+ assert REDACTED in out
+
+
+def test_redact_preserves_short_hash():
+ short = "aabbccddeeff00112233445566778899"
+ out = redact_diagnostic_text(f"peer {short}")
+ assert short in out
+
+
+def test_redact_empty():
+ assert redact_diagnostic_text("") == ""
+ assert redact_diagnostic_text(None) is None # type: ignore[arg-type]
diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index c7fdbeeb..d1ae9d1e 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -80,7 +80,8 @@ class TestPluginManagerInstall:
{"limit": 10},
)
assert preview["line_count"] == 1
- assert "/home/user1/secret" in preview["log_text"]
+ assert "/home/user1/secret" not in preview["log_text"]
+ assert "[redacted]" in preview["log_text"]
def test_rns_link_capabilities_require_manifest_grant(self, tmp_path):
class FakeLinkManager:
diff --git a/tests/e2e/acceptance-settings-privacy.spec.js b/tests/e2e/acceptance-settings-privacy.spec.js
new file mode 100644
index 00000000..62fc4fa2
--- /dev/null
+++ b/tests/e2e/acceptance-settings-privacy.spec.js
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: 0BSD
+/**
+ * Acceptance: Settings privacy surface is reachable and shows expected controls.
+ */
+const { test, expect } = require("@playwright/test");
+const { prepareE2eSession } = require("./helpers");
+
+test.describe("Acceptance: Settings privacy", () => {
+ test.beforeEach(async ({ request }) => {
+ await prepareE2eSession(request);
+ });
+
+ test("privacy tab shows data controls and privacy mode", async ({ page }) => {
+ await page.goto("/#/settings");
+ await expect(page).toHaveURL(/#\/settings/);
+ await expect(page.getByText("Profile", { exact: true }).first()).toBeVisible({
+ timeout: 30000,
+ });
+
+ const privacyTab = page.getByRole("button", { name: /^Privacy$/i }).first();
+ await expect(privacyTab).toBeVisible({ timeout: 20000 });
+ await privacyTab.click();
+
+ await expect(page.getByText("Data & device", { exact: true }).first()).toBeVisible({
+ timeout: 20000,
+ });
+ await expect(
+ page.getByText("Privacy mode (block external HTTP/HTTPS)", { exact: true }).first(),
+ ).toBeVisible({ timeout: 20000 });
+ });
+});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index ec993c2f..c403a734 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -291,8 +291,11 @@ describe("behavior contracts: network visualiser performance", () => {
expect(engine).toContain('pointerMode = "pinch"');
expect(engine).toContain("meshchatxVisualiserSceneZoomAt");
expect(engine).toContain("updateNodeImages");
+ expect(engine).toContain("labelByIndex");
const webgl = readSource("meshchatx/src/frontend/js/networkVisualiserWebGL.js");
expect(webgl).toContain("u_atlas");
+ expect(webgl).toContain("network-webgl-labels");
+ expect(webgl).toContain("resolveVisualiserAssetUrl");
expect(webgl).toContain("mergeSceneNodesWithTextures");
const prefs = readSource("meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js");
expect(prefs).toContain("persistVisualiserRenderer");
diff --git a/tests/frontend/hotPathBugRegressions.test.js b/tests/frontend/hotPathBugRegressions.test.js
index f6f24fd3..e7505287 100644
--- a/tests/frontend/hotPathBugRegressions.test.js
+++ b/tests/frontend/hotPathBugRegressions.test.js
@@ -43,4 +43,24 @@ describe("hot-path bug regressions", () => {
expect(DownloadUtils.sanitizeDownloadFilename("a\r\nb.html", "x.bin")).toBe("ab.html");
expect(DownloadUtils.sanitizeDownloadFilename("a\x00b.bin", "x.bin")).toBe("ab.bin");
});
+
+ it("strips zero-width and soft-hyphen hidden fixed overlays", () => {
+ const zwsp = String.fromCharCode(0x200b);
+ const shyChar = String.fromCharCode(0x00ad);
+ const zw = MicronParser.stripOverlayStyles(`<div style="position:fi${zwsp}xed; color:red">x</div>`);
+ expect(zw.toLowerCase()).not.toMatch(/position\s*:\s*fi/);
+ expect(zw).toContain("color:red");
+ const shy = MicronParser.stripOverlayStyles(`<div style="position:fi${shyChar}xed">x</div>`);
+ expect(shy.toLowerCase()).not.toMatch(/position\s*:/);
+ const cssZw = stripOverlayFromCss(`.x{position:fi${zwsp}xed;inset:0}`);
+ expect(cssZw.toLowerCase()).not.toMatch(/position\s*:\s*fi/);
+ expect(cssZw.toLowerCase()).toMatch(/position:static/);
+ });
+
+ it("download filenames strip bidi overrides and Windows reserved names", () => {
+ const rtl = String.fromCharCode(0x202e);
+ expect(DownloadUtils.sanitizeDownloadFilename(`${rtl}exe.txt`, "x.bin")).toBe("exe.txt");
+ expect(DownloadUtils.sanitizeDownloadFilename("CON.txt", "x.bin")).toBe("x.bin");
+ expect(DownloadUtils.sanitizeDownloadFilename("evil.txt...", "x.bin")).toBe("evil.txt");
+ });
});
diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js
index 1cf7e876..391b9765 100644
--- a/tests/frontend/networkVisualiserWebGLEngine.test.js
+++ b/tests/frontend/networkVisualiserWebGLEngine.test.js
@@ -15,6 +15,7 @@ import {
import {
atlasUvForSlot,
mergeSceneNodesWithTextures,
+ resolveVisualiserAssetUrl,
SCENE_NODE_STRIDE,
NODE_STRIDE,
ATLAS_COLS,
@@ -109,6 +110,7 @@ function stubGl() {
TEXTURE_WRAP_S: 0x2802,
TEXTURE_WRAP_T: 0x2803,
UNPACK_FLIP_Y_WEBGL: 0x9240,
+ UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241,
COLOR_BUFFER_BIT: 0x4000,
BLEND: 0x0be2,
SRC_ALPHA: 0x0302,
@@ -127,15 +129,35 @@ function stubGl() {
}
function makeCanvas(gl) {
+ const host = document.createElement("div");
+ host.style.cssText = "position:relative;width:400px;height:300px;";
const canvas = document.createElement("canvas");
+ host.appendChild(canvas);
+ document.body.appendChild(host);
Object.defineProperty(canvas, "clientWidth", { value: 400 });
Object.defineProperty(canvas, "clientHeight", { value: 300 });
canvas.getBoundingClientRect = () => ({ left: 10, top: 20, width: 400, height: 300 });
canvas.setPointerCapture = vi.fn();
canvas.releasePointerCapture = vi.fn();
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type) {
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type, opts) {
if (this === canvas && type === "webgl2") return gl;
- if (type === "2d") return { clearRect: vi.fn(), drawImage: vi.fn() };
+ if (type === "2d") {
+ return {
+ clearRect: vi.fn(),
+ drawImage: vi.fn(),
+ getImageData: () => ({
+ data: new Uint8ClampedArray(64 * 64 * 4).fill(255),
+ width: 64,
+ height: 64,
+ }),
+ putImageData: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ setTransform: vi.fn(),
+ strokeText: vi.fn(),
+ fillText: vi.fn(),
+ };
+ }
return null;
});
return canvas;
@@ -251,6 +273,19 @@ describe("networkVisualiserWebGL textures", () => {
expect(last.v).toBeCloseTo((ATLAS_ROWS - 1) / ATLAS_ROWS);
});
+ it("resolveVisualiserAssetUrl keeps blob/data/http URLs", () => {
+ expect(resolveVisualiserAssetUrl("blob:http://localhost/x")).toBe("blob:http://localhost/x");
+ expect(resolveVisualiserAssetUrl("data:image/png;base64,xx")).toBe("data:image/png;base64,xx");
+ expect(resolveVisualiserAssetUrl("https://example.com/a.png")).toBe("https://example.com/a.png");
+ });
+
+ it("resolveVisualiserAssetUrl absolutizes root paths", () => {
+ const origin = window.location.origin;
+ expect(resolveVisualiserAssetUrl("/assets/images/reticulum_logo_512.png")).toBe(
+ `${origin}/assets/images/reticulum_logo_512.png`
+ );
+ });
+
it("mergeSceneNodesWithTextures attaches atlas UVs", () => {
const scene = new Float32Array(SCENE_NODE_STRIDE);
scene[0] = 1;
@@ -345,6 +380,7 @@ describe("createVisualiserWebGLEngine interactions", () => {
engine.destroy();
engine = null;
}
+ canvas?.parentElement?.remove();
clearSceneGlobals();
vi.restoreAllMocks();
});
@@ -355,6 +391,7 @@ describe("createVisualiserWebGLEngine interactions", () => {
clearSceneGlobals();
const c = makeCanvas(stubGl());
expect(() => createVisualiserWebGLEngine(c)).toThrow(/WASM scene unavailable/);
+ c.parentElement?.remove();
});
it("pinch gesture calls SceneZoomAt with distance ratio", () => {
@@ -387,23 +424,25 @@ describe("createVisualiserWebGLEngine interactions", () => {
});
it("setGraph and updateNodeImages upload icon textures", async () => {
- const OriginalImage = globalThis.Image;
- globalThis.Image = class MockImage {
- constructor() {
- this.width = 32;
- this.height = 32;
- queueMicrotask(() => this.onload?.());
- }
- set src(_v) {
- /* onload via microtask */
- }
- };
+ const bitmap = { width: 32, height: 32, close: vi.fn() };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
+ }))
+ );
+ vi.stubGlobal(
+ "createImageBitmap",
+ vi.fn(async () => bitmap)
+ );
engine.setGraph(
[
{
id: "me",
group: "me",
+ label: "Local",
image: "/assets/images/reticulum_logo_512.png",
x: 0,
y: 0,
@@ -411,6 +450,7 @@ describe("createVisualiserWebGLEngine interactions", () => {
{
id: "peer",
group: "announce",
+ label: "Peer",
image: "/assets/images/network-visualiser/user.png",
x: 10,
y: 10,
@@ -431,8 +471,29 @@ describe("createVisualiserWebGLEngine interactions", () => {
await vi.waitFor(() => {
expect(gl.texSubImage2D.mock.calls.length).toBeGreaterThan(uploadsBefore);
});
+ });
- globalThis.Image = OriginalImage;
+ it("setGraph applies kind default icons when image missing", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ ok: true,
+ blob: async () => new Blob([new Uint8Array([9])], { type: "image/png" }),
+ }))
+ );
+ vi.stubGlobal(
+ "createImageBitmap",
+ vi.fn(async () => ({ width: 16, height: 16, close: vi.fn() }))
+ );
+
+ engine.setGraph([{ id: "me", group: "me", label: "Me", x: 0, y: 0 }], [], {
+ preserveCamera: false,
+ zoom: 1,
+ });
+ await vi.waitFor(() => {
+ expect(globalThis.fetch).toHaveBeenCalled();
+ expect(String(globalThis.fetch.mock.calls[0][0])).toContain("reticulum_logo_512.png");
+ });
});
it("sets touch-action none for mobile gestures", () => {
diff --git a/tests/frontend/securityExploratoryOracle.test.js b/tests/frontend/securityExploratoryOracle.test.js
index 8ad20d69..046ced49 100644
--- a/tests/frontend/securityExploratoryOracle.test.js
+++ b/tests/frontend/securityExploratoryOracle.test.js
@@ -37,6 +37,8 @@ const OVERLAY_STYLE_ATTACKS = [
'style="position/**/:fixed; top:0; left:0"',
'style="position: sticky !important; transform: translateY(0)"',
'style="POSITION:FiXeD; width:100vw; height:100vh"',
+ `style="position:fi${String.fromCharCode(0x200b)}xed; inset:0"`,
+ `style="position:fi${String.fromCharCode(0x00ad)}xed"`,
];
function assertSafeHtmlOracle(html) {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────